/*
 * PropertySet.java
 *
 * Created on 27 August 2004, 11:37
 *
 * Copyright 2008 the Desert Labs Project (http://desertlabs.us)
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package us.desertlabs.beans;

import java.beans.PropertyVetoException;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.logging.Level;
import java.util.logging.Logger;
import us.desertlabs.ClassHierarchyListFactory;

/**
 * This class can be used to obtain a view of any object as a set of bean-style
 * properties. After being given an <CODE>Object</CODE> in its constructor, it
 * uses an introspective process based on a combination of direct public field
 * access and bean-style accessor examination to build up a view of the object
 * as a "flat" set of properties. The <CODE>PropertySet</CODE> can then be
 * used directly to change the values of the properties of the object without
 * having to consider the API of the object itself. Additionally, the <CODE>PropertySet</CODE>
 * confers the benefits of a <CODE>VetoableChangeObject</CODE> (and, by
 * extension, a <CODE>PropertyChangeObject</CODE>), allowing any of the
 * various properties of the introspected <CODE>Object</CODE> to be listened
 * to and constrained.
 *
 * @author Brandon Franklin
 */
public class PropertySet extends VetoableChangeObject {

    /**
     * Given a primitive type name, returns the corresponding container class.
     *
     * @param clazz the <CODE>Class</CODE> of the primitive type to get the
     *        container class for
     * @return the <CODE>Class</CODE> of the container of the requested type
     */
    private static Class<?> getClassForPrimitive( final Class<?> clazz ) {
        if( clazz == boolean.class ) {
            return Boolean.class;
        } else if( clazz == byte.class ) {
            return Byte.class;
        } else if( clazz == char.class ) {
            return Character.class;
        } else if( clazz == short.class ) {
            return Short.class;
        } else if( clazz == int.class ) {
            return Integer.class;
        } else if( clazz == long.class ) {
            return Long.class;
        } else if( clazz == float.class ) {
            return Float.class;
        } else if( clazz == double.class ) {
            return Double.class;
        } else {
            throw new IllegalArgumentException( "Unknown primitive type requested." );
        }
    }

    /**
     * Given a primitive container class, returns the corresponding primitive
     * class.
     *
     * @param clazz the <CODE>Class</CODE> of the primitive container to get
     *        the primitive class for
     * @return the <CODE>Class</CODE> of the primitive of the requested type
     */
    private static Class<?> getPrimitiveForClass( final Class<?> clazz ) {
        if( clazz == Boolean.class ) {
            return boolean.class;
        } else if( clazz == Byte.class ) {
            return byte.class;
        } else if( clazz == Character.class ) {
            return char.class;
        } else if( clazz == Short.class ) {
            return short.class;
        } else if( clazz == Integer.class ) {
            return int.class;
        } else if( clazz == Long.class ) {
            return long.class;
        } else if( clazz == Float.class ) {
            return float.class;
        } else if( clazz == Double.class ) {
            return double.class;
        } else {
            throw new IllegalArgumentException( "Unknown primitive type requested." );
        }
    }

    /**
     * The <CODE>Class</CODE> specification in which each accessor method pair
     * resides
     */
    private final Map<String, Class<?>> accessorLocations;

    /**
     * The accessor-based properties of the object, expressed as a name-to-type
     * map
     */
    private final Map<String, Class<?>> accessorProps;

    /** The <CODE>Class</CODE> specification in which each public field resides */
    private final Map<String, Class<?>> fieldLocations;

    /**
     * The public <CODE>Field</CODE>s of the object, expressed as a
     * name-to-type map
     */
    private final Map<String, Class<?>> fields;

    /**
     * The <CODE>Object</CODE> whose properties are represented by this
     * instance
     */
    private final Object obj;

    /**
     * Creates a new instance of <CODE>PropertySet</CODE> to represent the
     * public properties of the supplied <CODE>Object</CODE>.
     *
     * @param obj the <CODE>Object</CODE> whose properties will be represented
     *        by this instance
     */
    public PropertySet( final Object obj ) {
        this.obj = obj;
        fields = new HashMap<String, Class<?>>();
        fieldLocations = new HashMap<String, Class<?>>();
        accessorProps = new HashMap<String, Class<?>>();
        accessorLocations = new HashMap<String, Class<?>>();

        for( final Class<?> clazz : ClassHierarchyListFactory.createAssignableList( obj.getClass() ) ) {
            findPublicFields( clazz );
            findAccessorMethods( clazz );
        }
    }

    /**
     * Returns the current value of the requested property name.
     *
     * @param propName the name of the property to retrieve the value of
     * @return the current value of the requested property name
     * @throws IllegalArgumentException if no such property exists
     */
    public Object get( final String propName ) {

        Object returnVal = null;

        if( fields.containsKey( propName ) ) {
            final Class<?> fieldLocation = fieldLocations.get( propName );

            Field field = null;
            try {
                field = fieldLocation.getDeclaredField( propName );
            } catch( final SecurityException e ) {
                Logger.getLogger( getClass().getName() ).log( Level.SEVERE, "Error while retrieving field", e );
            } catch( final NoSuchFieldException e ) {
                Logger.getLogger( getClass().getName() ).log( Level.SEVERE, "Error while retrieving field", e );
            }


            try {
                returnVal = (field != null ? field.get( obj ) : null);
            } catch( final IllegalArgumentException e ) {
                Logger.getLogger( getClass().getName() ).log( Level.SEVERE, "Error while retrieving value of field", e );
            } catch( final IllegalAccessException e ) {
                Logger.getLogger( getClass().getName() ).log( Level.SEVERE, "Error while retrieving value of field", e );
            }
        } else if( accessorProps.containsKey( propName ) ) {

            // ensure first letter is uppercase
            final char[] chars = propName.toCharArray();
            chars[0] = Character.toUpperCase( chars[0] );
            final String newPropName = String.valueOf( chars );

            // Figure out whether to use "get" or "is"
            final Class<?> datatype = accessorProps.get( propName );
            final String prefix = (datatype == Boolean.class) ? "is" : "get";

            final Class<?> accessorLocation = accessorLocations.get( propName );
            Method method = null;
            try {
                method = accessorLocation.getDeclaredMethod( prefix + newPropName );
            } catch( final SecurityException e ) {
                Logger.getLogger( getClass().getName() ).log( Level.SEVERE, "Error while retrieving accessor method", e );
            } catch( final NoSuchMethodException e ) {
                Logger.getLogger( getClass().getName() ).log( Level.SEVERE, "Error while retrieving accessor method", e );
            }

            if( method != null ) {
                try {
                    returnVal = method.invoke( obj );
                } catch( final IllegalArgumentException e ) {
                    Logger.getLogger( getClass().getName() ).log( Level.SEVERE, "Error while executing accessor method", e );
                } catch( final IllegalAccessException e ) {
                    Logger.getLogger( getClass().getName() ).log( Level.SEVERE, "Error while executing accessor method", e );
                } catch( final InvocationTargetException e ) {
                    Logger.getLogger( getClass().getName() ).log( Level.SEVERE, "Error while executing accessor method", e );
                }
            }
        } else {
            throw new IllegalArgumentException( "There is no such property in this set." );
        }

        return returnVal;
    }

    /**
     * Returns a <CODE>Map</CODE> of all of the properties that can be set
     * through the use of this instance of <CODE>PropertySet</CODE>. The
     * names of the properties are the keys of the <CODE>Map</CODE>, and the
     * datatypes are the values. The names will be sorted in ascending
     * alphabetical order.
     *
     * @return a <CODE>Map</CODE> of all of the properties that can be set
     *         through the use of this instance of <CODE>PropertySet</CODE>
     */
    public Map<String, Class<?>> getProperties() {
        final Map<String, Class<?>> combinedMap = new TreeMap<String, Class<?>>();
        combinedMap.putAll( fields );
        combinedMap.putAll( accessorProps );
        return combinedMap;
    }

    /**
     * Returns an array of the names of all of the properties that can be set
     * through the use of this instance of <CODE>PropertySet</CODE>. The
     * names will be sorted in ascending alphabetical order.
     *
     * @return an array of the names of all of the properties that can be set
     *         through the use of this instance of <CODE>PropertySet</CODE>
     */
    public String[] getPropertyNames() {
        final Set<String> combinedKeys = getProperties().keySet();
        final String[] strArray = new String[combinedKeys.size()];
        return combinedKeys.toArray( strArray );
    }

    /**
     * Sets the current value of the requested property name.
     *
     * @param propName the name of the property to set the value of
     * @param value the new value of the property
     * @throws IllegalArgumentException if no such property exists or if the
     *         datatype of the value does not match that of the property
     */
    public void set( final String propName, final Object value ) {

        if( fields.containsKey( propName ) ) {
            final Class<?> datatype = fields.get( propName );

            if( !datatype.isAssignableFrom( value.getClass() ) ) {
                throw new IllegalArgumentException( "Supplied value is not of the correct datatype." );
            }

            final Class<?> fieldLocation = fieldLocations.get( propName );
            Object oldValue = null;
            Field field = null;
            try {
                field = fieldLocation.getDeclaredField( propName );
            } catch( final SecurityException e ) {
                Logger.getLogger( getClass().getName() ).log( Level.SEVERE, "Error while retrieving field", e );
            } catch( final NoSuchFieldException e ) {
                Logger.getLogger( getClass().getName() ).log( Level.SEVERE, "Error while retrieving field", e );

            }

            if( field != null ) {
                try {
                    oldValue = field.get( fieldLocation );
                    field.set( obj, value );
                    getPropertyChangeSupport().firePropertyChange( propName, oldValue, value );
                    try {
                        getVetoableChangeSupport().fireVetoableChange( propName, oldValue, value );
                    } catch( final PropertyVetoException pve ) {
                        field.set( obj, oldValue );
                        getPropertyChangeSupport().firePropertyChange( propName, value, oldValue );
                    }
                } catch( final IllegalArgumentException e ) {
                    Logger.getLogger( getClass().getName() ).log( Level.SEVERE, "Error while accessing value of field", e );
                } catch( final IllegalAccessException e ) {
                    Logger.getLogger( getClass().getName() ).log( Level.SEVERE, "Error while accessing value of field", e );
                }
            }

        } else if( accessorProps.containsKey( propName ) ) {
            Class<?> datatype = accessorProps.get( propName );

            if( !datatype.isAssignableFrom( value.getClass() ) ) {
                throw new IllegalArgumentException( "Supplied value is not of the correct datatype." );
            }

            // ensure first letter is uppercase
            final char[] chars = propName.toCharArray();
            chars[0] = Character.toUpperCase( chars[0] );
            final String newPropName = String.valueOf( chars );

            final Class<?> accessorLocation = accessorLocations.get( propName );
            Method method = null;
            try {
                method = accessorLocation.getDeclaredMethod( "set" + newPropName, datatype );
            } catch( final NoSuchMethodException nsme ) {
                // Nothing to do yet
            }

            if( method == null ) { // Could be a primitive in the wrong form
                try {
                    datatype = getPrimitiveForClass( datatype );
                    method = accessorLocation.getDeclaredMethod( "set" + newPropName, datatype );
                } catch( final NoSuchMethodException nsme ) {
                    // This is bad. Time to give up.
                    throw new IllegalArgumentException( "There is no such property in this set." );
                }
            }

            final Object oldValue = get( propName );
            try {
                method.invoke( obj, value );
                getPropertyChangeSupport().firePropertyChange( propName, oldValue, value );
                try {
                    getVetoableChangeSupport().fireVetoableChange( propName, oldValue, value );
                } catch( final PropertyVetoException pve ) {
                    method.invoke( obj, oldValue );
                    getPropertyChangeSupport().firePropertyChange( propName, value, oldValue );
                }
            } catch( final IllegalArgumentException e ) {
                Logger.getLogger( getClass().getName() ).log( Level.SEVERE, "Error while executing accessor method.", e );
            } catch( final IllegalAccessException e ) {
                Logger.getLogger( getClass().getName() ).log( Level.SEVERE, "Error while executing accessor method.", e );
            } catch( final InvocationTargetException e ) {
                Logger.getLogger( getClass().getName() ).log( Level.SEVERE, "Error while executing accessor method.", e );
            }

        } else {
            throw new IllegalArgumentException( "There is no such property in this set." );
        }

    }

    /**
     * Searches the provided <CODE>Class</CODE> for properties that can be set
     * and read through the use of accessors, and adds them all to the <CODE>Set</CODE>
     * of accessor properties.
     *
     * @param clazz the <CODE>Class</CODE> to search for accessor properties in
     */
    private void findAccessorMethods( final Class<?> clazz ) {
        final Method[] methods = clazz.getDeclaredMethods();

        // Find all the methods that start with "set" or "get"
        final Set<Method> setMethods = new HashSet<Method>();
        final Set<Method> getMethods = new HashSet<Method>();
        for( final Method method : methods ) {

            // Reject methods that aren't public
            if( !Modifier.isPublic( method.getModifiers() ) ) {
                continue;
            }

            // "Set" methods
            if( method.getName().startsWith( "set" ) ) {

                // Reject methods that don't have one parameter
                if( method.getParameterTypes().length != 1 ) {
                    continue;
                }

                setMethods.add( method );
            } else // "Get" methods
            if( method.getName().startsWith( "get" ) || (method.getName().startsWith( "is" )) ) {

                // Reject methods that have parameters
                if( method.getParameterTypes().length != 0 ) {
                    continue;
                }

                getMethods.add( method );
            }
        }

        // Keep only matched set/get pairs
        for( final Method setMethod : setMethods ) {

            String propName = setMethod.getName().substring( 3 );
            Class<?> datatype = setMethod.getParameterTypes()[0];

            for( final Method getMethod : getMethods ) {

                // try to match the names
                if( getMethod.getName().startsWith( "is" ) ) {
                    if( !getMethod.getName().substring( 2 ).equals( propName ) ) {
                        continue;
                    }
                } else {
                    if( !getMethod.getName().substring( 3 ).equals( propName ) ) {
                        continue;
                    }
                }

                // try to match the datatypes
                if( getMethod.getReturnType().equals( datatype ) ) {

                    // ensure first letter is lowercase
                    final char[] chars = propName.toCharArray();
                    chars[0] = Character.toLowerCase( chars[0] );
                    propName = String.valueOf( chars );

                    if( datatype.isPrimitive() ) {
                        datatype = getClassForPrimitive( datatype );
                    }
                    accessorProps.put( propName, datatype );
                    accessorLocations.put( propName, clazz );
                }
            }

        }

    }

    /**
     * Searches the provided <CODE>Class</CODE> for public fields, and adds
     * them all to the <CODE>Set</CODE> of fields.
     *
     * @param clazz the <CODE>Class</CODE> to search for public fields in
     */
    private void findPublicFields( final Class<?> clazz ) {
        final Field[] declaredFields = clazz.getDeclaredFields();

        for( final Field field : declaredFields ) {

            final int modifiers = field.getModifiers();
            if( Modifier.isFinal( modifiers ) ) {
                continue;
            }

            if( Modifier.isPublic( modifiers ) ) {

                Class<?> datatype = field.getType();
                if( datatype.isPrimitive() ) {
                    datatype = getClassForPrimitive( datatype );
                }

                fields.put( field.getName(), datatype );
                fieldLocations.put( field.getName(), clazz );
            }
        }
    }
}
